# typed: strict
# frozen_string_literal: true

require "bump_version_parser"
require "cli/parser"
require "livecheck/livecheck"

module Homebrew
  module_function

  class VersionBumpInfo < T::Struct
    const :type, Symbol
    const :multiple_versions, T::Boolean
    const :version_name, String
    const :current_version, BumpVersionParser
    const :repology_latest, T.any(String, Version)
    const :new_version, BumpVersionParser
    const :open_pull_requests, T.nilable(T.any(T::Array[String], String))
    const :closed_pull_requests, T.nilable(T.any(T::Array[String], String))
  end

  sig { returns(CLI::Parser) }
  def bump_args
    CLI::Parser.new do
      description <<~EOS
        Display out-of-date brew formulae and the latest version available. If the
        returned current and livecheck versions differ or when querying specific
        formulae, also displays whether a pull request has been opened with the URL.
      EOS
      switch "--full-name",
             description: "Print formulae/casks with fully-qualified names."
      switch "--no-pull-requests",
             description: "Do not retrieve pull requests from GitHub."
      switch "--formula", "--formulae",
             description: "Check only formulae."
      switch "--cask", "--casks",
             description: "Check only casks."
      switch "--installed",
             description: "Check formulae and casks that are currently installed."
      switch "--open-pr",
             description: "Open a pull request for the new version if none have been opened yet."
      flag   "--limit=",
             description: "Limit number of package results returned."
      flag   "--start-with=",
             description: "Letter or word that the list of package results should alphabetically follow."
      switch "-f", "--force",
             description: "Ignore duplicate open PRs.",
             hidden:      true

      conflicts "--cask", "--formula"
      conflicts "--no-pull-requests", "--open-pr"

      named_args [:formula, :cask], without_api: true
    end
  end

  sig { void }
  def bump
    args = bump_args.parse

    Homebrew.install_bundler_gems!(groups: ["livecheck"])

    if args.limit.present? && !args.formula? && !args.cask?
      raise UsageError, "`--limit` must be used with either `--formula` or `--cask`."
    end

    Homebrew.with_no_api_env do
      formulae_and_casks = if args.installed?
        formulae = args.cask? ? [] : Formula.installed
        casks = args.formula? ? [] : Cask::Caskroom.casks
        formulae + casks
      elsif args.named.present?
        if args.formula?
          args.named.to_formulae
        elsif args.cask?
          args.named.to_casks
        else
          args.named.to_formulae_and_casks
        end
      end

      formulae_and_casks = formulae_and_casks&.sort_by do |formula_or_cask|
        formula_or_cask.respond_to?(:token) ? formula_or_cask.token : formula_or_cask.name
      end

      unless Utils::Curl.curl_supports_tls13?
        begin
          ensure_formula_installed!("curl", reason: "Repology queries") unless HOMEBREW_BREWED_CURL_PATH.exist?
        rescue FormulaUnavailableError
          opoo "A newer `curl` is required for Repology queries."
        end
      end

      if formulae_and_casks.present?
        handle_formula_and_casks(formulae_and_casks, args)
      else
        handle_api_response(args)
      end
    end
  end

  sig { params(formulae_and_casks: T::Array[T.any(Formula, Cask::Cask)], args: CLI::Args).void }
  def handle_formula_and_casks(formulae_and_casks, args)
    Livecheck.load_other_tap_strategies(formulae_and_casks)

    ambiguous_casks = []
    if !args.formula? && !args.cask?
      ambiguous_casks = formulae_and_casks
                        .group_by { |item| Livecheck.package_or_resource_name(item, full_name: true) }
                        .values
                        .select { |items| items.length > 1 }
                        .flatten
                        .select { |item| item.is_a?(Cask::Cask) }
    end

    ambiguous_names = []
    unless args.full_name?
      ambiguous_names = (formulae_and_casks - ambiguous_casks)
                        .group_by { |item| Livecheck.package_or_resource_name(item) }
                        .values
                        .select { |items| items.length > 1 }
                        .flatten
    end

    formulae_and_casks.each_with_index do |formula_or_cask, i|
      puts if i.positive?
      next if skip_ineligible_formulae(formula_or_cask)

      use_full_name = args.full_name? || ambiguous_names.include?(formula_or_cask)
      name = Livecheck.package_or_resource_name(formula_or_cask, full_name: use_full_name)
      repository = if formula_or_cask.is_a?(Formula)
        Repology::HOMEBREW_CORE
      else
        Repology::HOMEBREW_CASK
      end

      package_data = if formula_or_cask.is_a?(Formula) && formula_or_cask.versioned_formula?
        nil
      else
        Repology.single_package_query(name, repository: repository)
      end

      retrieve_and_display_info_and_open_pr(
        formula_or_cask,
        name,
        package_data&.values&.first,
        args:           args,
        ambiguous_cask: ambiguous_casks.include?(formula_or_cask),
      )
    end
  end

  sig { params(args: CLI::Args).void }
  def handle_api_response(args)
    limit = args.limit.to_i if args.limit.present?

    api_response = {}
    unless args.cask?
      api_response[:formulae] =
        Repology.parse_api_response(limit, args.start_with, repository: Repology::HOMEBREW_CORE)
    end
    unless args.formula?
      api_response[:casks] =
        Repology.parse_api_response(limit, args.start_with, repository: Repology::HOMEBREW_CASK)
    end

    api_response.each_with_index do |(package_type, outdated_packages), idx|
      repository = if package_type == :formulae
        Repology::HOMEBREW_CORE
      else
        Repology::HOMEBREW_CASK
      end
      puts if idx.positive?
      oh1 package_type.capitalize if api_response.size > 1

      outdated_packages.each_with_index do |(_name, repositories), i|
        break if limit && i >= limit

        homebrew_repo = repositories.find do |repo|
          repo["repo"] == repository
        end

        next if homebrew_repo.blank?

        formula_or_cask = begin
          if repository == Repology::HOMEBREW_CORE
            Formula[homebrew_repo["srcname"]]
          else
            Cask::CaskLoader.load(homebrew_repo["srcname"])
          end
        rescue
          next
        end
        name = Livecheck.package_or_resource_name(formula_or_cask)
        ambiguous_cask = begin
          formula_or_cask.is_a?(Cask::Cask) && !args.cask? && Formula[name]
        rescue FormulaUnavailableError
          false
        end

        puts if i.positive?
        next if skip_ineligible_formulae(formula_or_cask)

        retrieve_and_display_info_and_open_pr(
          formula_or_cask,
          name,
          repositories,
          args:           args,
          ambiguous_cask: ambiguous_cask,
        )
      end
    end
  end

  sig {
    params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T::Boolean)
  }
  def skip_ineligible_formulae(formula_or_cask)
    if formula_or_cask.is_a?(Formula)
      return false if !formula_or_cask.disabled? && !formula_or_cask.head_only?

      name = formula_or_cask.name
      text = "Formula is #{formula_or_cask.disabled? ? "disabled" : "HEAD-only"}.\n"
    else
      return false unless formula_or_cask.disabled?

      name = formula_or_cask.token
      text = "Cask is disabled.\n"
    end
    ohai name
    puts text
    true
  end

  sig {
    params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(T.any(Version, String))
  }
  def livecheck_result(formula_or_cask)
    name = Livecheck.package_or_resource_name(formula_or_cask)

    referenced_formula_or_cask, = Livecheck.resolve_livecheck_reference(
      formula_or_cask,
      full_name: false,
      debug:     false,
    )

    # Check skip conditions for a referenced formula/cask
    if referenced_formula_or_cask
      skip_info = Livecheck::SkipConditions.referenced_skip_information(
        referenced_formula_or_cask,
        name,
        full_name: false,
        verbose:   false,
      )
    end

    skip_info ||= Livecheck::SkipConditions.skip_information(
      formula_or_cask,
      full_name: false,
      verbose:   false,
    )

    if skip_info.present?
      return "#{skip_info[:status]}#{" - #{skip_info[:messages].join(", ")}" if skip_info[:messages].present?}"
    end

    version_info = Livecheck.latest_version(
      formula_or_cask,
      referenced_formula_or_cask: referenced_formula_or_cask,
      json: true, full_name: false, verbose: true, debug: false
    )
    return "unable to get versions" if version_info.blank?

    latest = version_info[:latest]

    Version.new(latest)
  rescue => e
    "error: #{e}"
  end

  sig {
    params(
      formula_or_cask: T.any(Formula, Cask::Cask),
      name:            String,
      state:           String,
      version:         T.nilable(String),
    ).returns T.nilable(T.any(T::Array[String], String))
  }
  def retrieve_pull_requests(formula_or_cask, name, state:, version: nil)
    tap_remote_repo = formula_or_cask.tap&.remote_repo || formula_or_cask.tap&.full_name
    pull_requests = begin
      GitHub.fetch_pull_requests(name, tap_remote_repo, state: state, version: version)
    rescue GitHub::API::ValidationFailedError => e
      odebug "Error fetching pull requests for #{formula_or_cask} #{name}: #{e}"
      nil
    end

    if pull_requests&.any?
      pull_requests = pull_requests.map { |pr| "#{pr["title"]} (#{Formatter.url(pr["html_url"])})" }.join(", ")
    end

    pull_requests
  end

  sig {
    params(
      formula_or_cask: T.any(Formula, Cask::Cask),
      repositories:    T::Array[T.untyped],
      args:            T.untyped,
      name:            String,
    ).returns(VersionBumpInfo)
  }
  def retrieve_versions_by_arch(formula_or_cask:, repositories:, args:, name:)
    is_cask_with_blocks = formula_or_cask.is_a?(Cask::Cask) && formula_or_cask.on_system_blocks_exist?
    type, version_name = formula_or_cask.is_a?(Formula) ? [:formula, "formula version:"] : [:cask, "cask version:   "]

    old_versions = {}
    new_versions = {}

    repology_latest = repositories.present? ? Repology.latest_version(repositories) : "not found"

    # When blocks are absent, arch is not relevant. For consistency, we simulate the arm architecture.
    arch_options = is_cask_with_blocks ? OnSystem::ARCH_OPTIONS : [:arm]

    arch_options.each do |arch|
      SimulateSystem.with arch: arch do
        version_key = is_cask_with_blocks ? arch : :general

        # We reload the formula/cask here to ensure we're getting the correct version for the current arch
        if formula_or_cask.is_a?(Formula)
          loaded_formula_or_cask = formula_or_cask
          current_version_value = T.must(loaded_formula_or_cask.stable).version
        else
          loaded_formula_or_cask = Cask::CaskLoader.load(formula_or_cask.sourcefile_path)
          current_version_value = Version.new(loaded_formula_or_cask.version)
        end

        livecheck_latest = livecheck_result(loaded_formula_or_cask)

        new_version_value = if (livecheck_latest.is_a?(Version) && livecheck_latest >= current_version_value) ||
                               current_version_value == "latest"
          livecheck_latest
        elsif livecheck_latest.is_a?(String) && livecheck_latest.start_with?("skipped")
          "skipped"
        elsif repology_latest.is_a?(Version) &&
              repology_latest > current_version_value &&
              !loaded_formula_or_cask.livecheckable? &&
              current_version_value != "latest"
          repology_latest
        end.presence

        # Store old and new versions
        old_versions[version_key] = current_version_value
        new_versions[version_key] = new_version_value
      end
    end

    # If arm and intel versions are identical, as it happens with casks where only the checksums differ,
    # we consolidate them into a single version.
    if old_versions[:arm].present? && old_versions[:arm] == old_versions[:intel]
      old_versions = { general: old_versions[:arm] }
    end
    if new_versions[:arm].present? && new_versions[:arm] == new_versions[:intel]
      new_versions = { general: new_versions[:arm] }
    end

    multiple_versions = old_versions.values_at(:arm, :intel).all?(&:present?) ||
                        new_versions.values_at(:arm, :intel).all?(&:present?)

    current_version = BumpVersionParser.new(general: old_versions[:general],
                                            arm:     old_versions[:arm],
                                            intel:   old_versions[:intel])

    begin
      new_version = BumpVersionParser.new(general: new_versions[:general],
                                          arm:     new_versions[:arm],
                                          intel:   new_versions[:intel])
    rescue
      # When livecheck fails, we fail gracefully. Otherwise VersionParser will
      # raise a usage error
      new_version = BumpVersionParser.new(general: "unable to get versions")
    end

    # We use the arm version for the pull request version. This is consistent
    # with the behavior of bump-cask-pr.
    pull_request_version = if multiple_versions && new_version.general != "unable to get versions"
      new_version.arm.to_s
    else
      new_version.general.to_s
    end

    open_pull_requests = if !args.no_pull_requests? && (args.named.present? || new_version.present?)
      retrieve_pull_requests(formula_or_cask, name, state: "open")
    end.presence

    closed_pull_requests = if !args.no_pull_requests? && open_pull_requests.blank? && new_version.present?
      retrieve_pull_requests(formula_or_cask, name, state: "closed", version: pull_request_version)
    end.presence

    VersionBumpInfo.new(
      type:                 type,
      multiple_versions:    multiple_versions,
      version_name:         version_name,
      current_version:      current_version,
      repology_latest:      repology_latest,
      new_version:          new_version,
      open_pull_requests:   open_pull_requests,
      closed_pull_requests: closed_pull_requests,
    )
  end

  sig {
    params(
      formula_or_cask: T.any(Formula, Cask::Cask),
      name:            String,
      repositories:    T::Array[T.untyped],
      args:            T.untyped,
      ambiguous_cask:  T::Boolean,
    ).void
  }
  def retrieve_and_display_info_and_open_pr(formula_or_cask, name, repositories, args:, ambiguous_cask: false)
    version_info = retrieve_versions_by_arch(formula_or_cask: formula_or_cask,
                                             repositories:    repositories,
                                             args:            args,
                                             name:            name)

    current_version = version_info.current_version
    new_version = version_info.new_version
    repology_latest = version_info.repology_latest

    # Check if all versions are equal
    versions_equal = [:arm, :intel, :general].all? do |key|
      current_version.send(key) == new_version.send(key)
    end

    title_name = ambiguous_cask ? "#{name} (cask)" : name
    title = if (repology_latest == current_version.general || !repology_latest.is_a?(Version)) && versions_equal
      "#{title_name} #{Tty.green}is up to date!#{Tty.reset}"
    else
      title_name
    end

    # Conditionally format output based on type of formula_or_cask
    current_versions = if version_info.multiple_versions
      "arm:   #{current_version.arm}
                          intel: #{current_version.intel}"
    else
      current_version.general.to_s
    end
    current_versions << " (deprecated)" if formula_or_cask.deprecated?

    new_versions = if version_info.multiple_versions && new_version.arm && new_version.intel
      "arm:   #{new_version.arm}
                          intel: #{new_version.intel}"
    else
      new_version.general
    end

    version_label = version_info.version_name
    open_pull_requests = version_info.open_pull_requests.presence
    closed_pull_requests = version_info.closed_pull_requests.presence

    ohai title
    puts <<~EOS
      Current #{version_label}  #{current_versions}
      Latest livecheck version: #{new_versions}
      Latest Repology version:  #{repology_latest}
    EOS
    if formula_or_cask.is_a?(Formula)
      require "formula_auditor"
      auditor = FormulaAuditor.new(formula_or_cask)
      puts <<~EOS if auditor.synced_with_other_formulae?
        Version syncing:          #{title_name} version should be kept in sync with
                                  #{synced_with(auditor, formula_or_cask, new_version.general).join(", ")}.
      EOS
    end
    puts <<~EOS unless args.no_pull_requests?
      Open pull requests:       #{open_pull_requests || "none"}
      Closed pull requests:     #{closed_pull_requests || "none"}
    EOS

    return unless args.open_pr?

    if repology_latest.is_a?(Version) &&
       repology_latest > current_version.general &&
       repology_latest > new_version.general &&
       formula_or_cask.livecheckable?
      puts "#{title_name} was not bumped to the Repology version because it's livecheckable."
    end
    if new_version.blank? || versions_equal ||
       (!new_version.general.is_a?(Version) && !version_info.multiple_versions)
      return
    end

    return if !args.force? && (open_pull_requests.present? || closed_pull_requests.present?)

    version_args = if version_info.multiple_versions
      %W[--version-arm=#{new_version.arm} --version-intel=#{new_version.intel}]
    else
      "--version=#{new_version.general}"
    end

    bump_cask_pr_args = [
      "bump-#{version_info.type}-pr",
      name,
      *version_args,
      "--no-browse",
      "--message=Created by `brew bump`",
    ]
    bump_cask_pr_args << "--force" if args.force?

    system HOMEBREW_BREW_FILE, *bump_cask_pr_args
  end

  sig {
    params(
      auditor:     FormulaAuditor,
      formula:     Formula,
      new_version: T.nilable(T.any(Version, Cask::DSL::Version)),
    ).returns(T::Array[String])
  }
  def synced_with(auditor, formula, new_version)
    synced_with = []

    auditor.synced_versions_formulae_json.each do |synced_formulae|
      next unless synced_formulae.include?(formula.name)

      synced_formulae.each do |synced_formula|
        synced_formula = Formulary.factory(synced_formula)
        next if synced_formula == formula.name

        synced_with << synced_formula.name if synced_formula.version != new_version
      end
    end

    synced_with
  end
end
